SpanDurationMetricRecorder Class
Namespace: Diginsight.Diagnostics
Assembly: Diginsight.Diagnostics.dll
Automatically records span duration metrics for .NET activities, integrating with OpenTelemetry’s metrics pipeline.
public sealed class SpanDurationMetricRecorder : IActivityListenerLogicInheritance
Object ? SpanDurationMetricRecorder
Implements
IActivityListenerLogic
Summary
The SpanDurationMetricRecorder class is the core component responsible for automatically collecting duration metrics from .NET activities. It listens to activity lifecycle events and records the execution time of operations as OpenTelemetry histograms, enabling performance monitoring and analysis across your application.
Key capabilities: - ? Automatic metric collection when activities complete - ? Configurable filtering via IMetricRecordingFilter - ? Tag enrichment via IMetricRecordingEnricher - ? Class-aware options for fine-grained control - ? Exception safety - failures don’t affect application flow - ? OpenTelemetry integration - exports to any OTEL-compatible backend
Constructors
SpanDurationMetricRecorder(ILogger, IClassAwareOptions, IMeterFactory, IMetricRecordingFilter?, IMetricRecordingEnricher?)
Initializes a new instance of the SpanDurationMetricRecorder class.
public SpanDurationMetricRecorder(
ILogger<SpanDurationMetricRecorder> logger,
IClassAwareOptions<DiginsightActivitiesOptions> activitiesOptions,
IMeterFactory meterFactory,
IMetricRecordingFilter? recordingFilter = null,
IMetricRecordingEnricher? recordingEnricher = null
)Parameters
logger : ILogger<SpanDurationMetricRecorder>
Logger for diagnostic messages and error handling.
activitiesOptions : IClassAwareOptions<DiginsightActivitiesOptions>
Configuration options controlling metric recording behavior, including meter name, metric name, and recording flags.
meterFactory : IMeterFactory
Factory for creating OpenTelemetry meters and instruments.
recordingFilter : IMetricRecordingFilter? (optional)
Optional filter to control which activities should record metrics. If null, filtering is based on configuration only.
recordingEnricher : IMetricRecordingEnricher? (optional)
Optional enricher to add custom tags to metrics. If null, only default tags (span_name, status) are included.
Remarks
The histogram metric is created lazily on first use to avoid unnecessary overhead. The metric uses the meter name and metric name from DiginsightActivitiesOptions, defaulting to "diginsight.span_duration".
Methods
ActivityStopped(Activity)
Called when an activity stops, recording its duration as a metric.
void IActivityListenerLogic.ActivityStopped(Activity activity)Parameters
activity : Activity
The activity that has stopped.
Remarks
This method performs the following operations: 1. Retrieves the histogram from the lazy-initialized meter 2. Checks filtering rules via IMetricRecordingFilter or configuration 3. Builds tag array with span_name, status, and enriched tags 4. Records duration in milliseconds to the histogram 5. Handles exceptions gracefully with warning logs
The method has minimal performance overhead when recording is disabled due to early exit conditions.
Example
This method is called automatically by the .NET ActivityListener infrastructure:
// Your application code
using var activity = ActivitySource.StartMethodActivity(new { orderId = 123 });
await ProcessOrderAsync(orderId);
// Activity stops here, triggering ActivityStopped
// Metric recorded: diginsight.span_duration{span_name="ProcessOrderAsync", status="Ok"} = 250msSample(ref ActivityCreationOptions)
Determines the sampling behavior for new activities.
ActivitySamplingResult IActivityListenerLogic.Sample(
ref ActivityCreationOptions<ActivityContext> creationOptions
)Returns
ActivitySamplingResult
Always returns ActivitySamplingResult.AllData to ensure full activity data is available for metric collection.
Parameters
creationOptions : ActivityCreationOptions<ActivityContext>
The options for creating the activity (passed by reference).
Remarks
This method ensures that all activities have complete data (duration, tags, status) needed for accurate metric recording. The recorder does not participate in sampling decisions - filtering happens at metric recording time via IMetricRecordingFilter.
ActivityStarted(Activity)
Called when an activity starts (no-op for this recorder).
void IActivityListenerLogic.ActivityStarted(Activity activity)Parameters
activity : Activity
The activity that has started.
Remarks
This method has an empty implementation because span duration metrics only need to be recorded when activities complete. The method only exists in .NET Framework and .NET Standard 2.0 builds due to conditional compilation.
Usage Examples
Basic Registration
Register the recorder during application startup:
var builder = WebApplication.CreateBuilder(args);
// Register span duration metric recorder
builder.Services.AddSpanDurationMetricRecorder();
// Configure OpenTelemetry to export metrics
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddMeter("Diginsight.Diagnostics")
.AddPrometheusExporter());
var app = builder.Build();
app.Run();Configuration via appsettings.json
{
"Diginsight": {
"Activities": {
"RecordSpanDuration": true,
"SpanDurationMeterName": "MyApp.Telemetry",
"SpanDurationMetricName": "operation.duration",
"SpanDurationMetricDescription": "Duration of application operations",
"ActivitySources": {
"MyApp.*": true,
"System.*": false
}
}
}
}With Filtering
Register with a custom filter to control which activities record metrics:
builder.Services.AddSpanDurationMetricRecorder();
// Add filter to only record slow operations
builder.Services.AddSingleton<IMetricRecordingFilter, SlowOperationFilter>();
public class SlowOperationFilter : IMetricRecordingFilter
{
public bool? ShouldRecord(Activity activity, Instrument instrument)
{
// Only record operations longer than 100ms
return activity.Duration.TotalMilliseconds >= 100;
}
}With Enrichment
Add business context tags to metrics:
builder.Services.AddSpanDurationMetricRecorder();
builder.Services.AddSingleton<IMetricRecordingEnricher, BusinessContextEnricher>();
public class BusinessContextEnricher : IMetricRecordingEnricher
{
public Tags ExtractTags(Activity activity, Instrument instrument)
{
var tags = new List<Tag>();
// Add deployment environment
tags.Add(new Tag("environment",
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")));
// Extract customer tier from activity
if (activity.GetTagItem("customer_tier") is string tier)
tags.Add(new Tag("customer.tier", tier));
return tags;
}
}Application Code
Activities automatically generate metrics:
public class OrderService
{
private static readonly ActivitySource ActivitySource = new("MyApp.Orders");
public async Task<Order> ProcessOrderAsync(int orderId)
{
// Activity with rich context
using var activity = ActivitySource.StartRichActivity("ProcessOrder", new
{
orderId,
customer_tier = "premium", // Will be included if enricher configured
region = "us-east"
});
try
{
var order = await GetOrderFromDatabase(orderId);
await ValidateInventory(order);
await ProcessPayment(order);
activity?.SetOutput(new { order.Id, order.Status });
return order;
}
catch (Exception ex)
{
activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
throw;
}
// Metric automatically recorded when activity disposes:
// operation.duration{
// span_name="ProcessOrder",
// status="Ok",
// customer.tier="premium",
// environment="Production"
// } = 250ms
}
}Viewing Metrics in Prometheus
# Average order processing time
avg(operation_duration{span_name="ProcessOrder"})
# 95th percentile by customer tier
histogram_quantile(0.95,
rate(operation_duration_bucket{customer_tier="premium"}[5m]))
# Error rate
rate(operation_duration{status="Error"}[5m]) /
rate(operation_duration[5m])
Viewing Metrics in Application Insights
// Average duration by operation
customMetrics
| where name == "operation.duration"
| summarize avg(value) by tostring(customDimensions.span_name)
| order by avg_value desc
// 95th percentile for slow operations
customMetrics
| where name == "operation.duration"
| summarize percentile(value, 95) by tostring(customDimensions.span_name)
Configuration
DiginsightActivitiesOptions Properties
The recorder is configured through DiginsightActivitiesOptions:
| Property | Type | Default | Description |
|---|---|---|---|
RecordSpanDuration |
bool |
false |
Master switch to enable/disable metric recording |
SpanDurationMeterName |
string? |
null |
OpenTelemetry meter name (required if recording enabled) |
SpanDurationMetricName |
string? |
"diginsight.span_duration" |
Metric name in telemetry system |
SpanDurationMetricDescription |
string? |
null |
Human-readable metric description |
ActivitySources |
Dictionary<string, bool> |
{} |
Pattern-based filter for activity sources |
Filtering Logic
The recorder decides whether to record a metric using this precedence:
IMetricRecordingFilter.ShouldRecord()- if registered and returns non-nullDiginsightActivitiesOptions.RecordSpanDuration- global flag from configuration- Skip recording if both are false/null
if (!(recordingFilter?.ShouldRecord(activity, metric) ?? metricOptions.Record))
return; // Skip recordingExample filter combinations:
| Filter Return | Config Value | Result |
|---|---|---|
true |
false |
? Record (filter overrides) |
false |
true |
? Skip (filter overrides) |
null |
true |
? Record (uses config) |
null |
false |
? Skip (uses config) |
Tag Structure
Performance Considerations
Lazy Metric Creation
The histogram is created only when first needed:
private readonly Lazy<Histogram<double>> metricLazy;
// Created once on first ActivityStopped call
Histogram<double> metric = metricLazy.Value;Benefits: - ? No overhead if recording is disabled - ? Deferred initialization until configuration is available - ? Thread-safe creation
Early Exit Optimization
Fast filtering before expensive operations:
// Check filter first (microseconds)
if (!(recordingFilter?.ShouldRecord(activity, metric) ?? metricOptions.Record))
return; // Skip tag building and metric recording
// Only if recording is needed (milliseconds)
Tag[] tags = BuildTags(activity);
metric.Record(activity.Duration.TotalMilliseconds, tags);Exception Safety
Recording failures don’t crash the application:
try
{
metric.Record(activity.Duration.TotalMilliseconds, tags);
}
catch (Exception exception)
{
logger.LogWarning(exception, "Unhandled exception while recording...");
// Application continues normally
}Memory Efficiency
- Tag arrays are allocated only when recording
- Configuration options are frozen to prevent modifications
- No memory retention between recordings
Integration with OpenTelemetry
Meter Registration
The recorder creates a meter using IMeterFactory:
var meter = meterFactory.Create(metricOptions.MeterName);
var histogram = meter.CreateHistogram<double>(
metricOptions.MetricName,
"ms",
metricOptions.MetricDescription);Histogram Properties
- Type:
Histogram<double> - Unit:
"ms"(milliseconds) - Value:
activity.Duration.TotalMilliseconds - Aggregation: Supports percentiles (P50, P95, P99) and counts
Exporter Configuration
Metrics flow through OpenTelemetry’s standard pipeline:
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics
.AddMeter("MyApp.Telemetry") // Listen to your meter
.AddPrometheusExporter() // Export to Prometheus
.AddApplicationInsightsExporter() // Export to Azure
.AddOtlpExporter()); // Export via OTLPThread Safety
The SpanDurationMetricRecorder is thread-safe:
- ? Lazy initialization is thread-safe by design
- ? Histogram.Record() is thread-safe per OpenTelemetry spec
- ? No mutable state between recordings
- ? Options are frozen before use
Multiple activities can complete concurrently without synchronization issues.
Troubleshooting
No Metrics Appearing
Symptoms: Metrics don’t show up in your monitoring backend.
Checklist: 1. ? Is RecordSpanDuration = true in configuration? 2. ? Is the meter name registered in OpenTelemetry? 3. ? Are activity sources matched by configuration patterns? 4. ? Is an exporter configured and running? 5. ? Are activities actually being created and stopped?
Diagnostic code:
var options = serviceProvider.GetService<IOptions<DiginsightActivitiesOptions>>();
Console.WriteLine($"RecordSpanDuration: {options.Value.RecordSpanDuration}");
Console.WriteLine($"MeterName: {options.Value.SpanDurationMeterName}");High Cardinality Warning
Symptoms: Excessive storage costs or query performance issues.
Cause: Too many unique tag combinations (high cardinality).
Solution: Use IMetricRecordingEnricher to bucket high-cardinality values:
public class CardinalityControlEnricher : IMetricRecordingEnricher
{
public Tags ExtractTags(Activity activity, Instrument instrument)
{
var tags = new List<Tag>();
// ? Bad: customer_id has millions of values
// tags.Add(new Tag("customer_id", activity.GetTagItem("customer_id")));
// ? Good: customer_tier has only 3 values
tags.Add(new Tag("customer.tier", activity.GetTagItem("customer_tier")));
// ? Good: bucket order values
if (activity.GetTagItem("order_value") is double value)
{
var bucket = value switch
{
< 50 => "small",
< 200 => "medium",
< 1000 => "large",
_ => "enterprise"
};
tags.Add(new Tag("order.value_bucket", bucket));
}
return tags;
}
}Performance Impact
Symptoms: Increased CPU usage or latency.
Mitigation: 1. Use aggressive filtering to reduce recorded activities 2. Limit enricher complexity - avoid heavy computations 3. Monitor tag cardinality - keep unique combinations low 4. Consider sampling for high-throughput scenarios
public class SamplingFilter : IMetricRecordingFilter
{
private int counter = 0;
public bool? ShouldRecord(Activity activity, Instrument instrument)
{
// Record only 1 in 10 activities
return Interlocked.Increment(ref counter) % 10 == 0;
}
}Version History
| Version | Changes |
|---|---|
| 3.0.0 | Initial release with IMetricRecordingFilter and IMetricRecordingEnricher support |
| 3.1.0 | Added class-aware options support for per-component configuration |
| 3.2.0 | Improved exception handling and logging |
See Also
- How Metric Recording Works with Diginsight and OpenTelemetry
- IMetricRecordingFilter Interface
- IMetricRecordingEnricher Interface
- DiginsightActivitiesOptions Class
- OpenTelemetry Metrics Documentation
Remarks
The SpanDurationMetricRecorder is a foundational component of Diginsight’s observability stack, providing automatic, zero-configuration metric collection that integrates seamlessly with OpenTelemetry. By combining it with filters and enrichers, you can create sophisticated metrics pipelines tailored to your specific monitoring needs while maintaining high performance and low overhead.
Design principles: - ?? Zero-impact by default - no overhead when recording is disabled - ?? Highly extensible - filter and enricher extension points - ?? Exception-safe - never crashes your application - ?? Standards-based - uses OpenTelemetry conventions - ? Performance-optimized - lazy initialization and early exit